In [ ]:
# ========= Colab one-time setup (run once) =========
#!pip -q install tensorflow==2.16.1  # or keep Colab's default TF 2.x
!pip -q install opencv-python

import zipfile, os, glob

# === Uploaded zip file paths in Colab ===
TRAIN_ZIP = "/content/Stuctures_Dataset.zip"  # <-- training dataset
TEST_ZIP  = "/content/Dataset_test.zip"       # <-- test dataset

# === Extraction folders ===
DATA_ROOT = "/content/data_structures"
TRAIN_DIR = os.path.join(DATA_ROOT, "train")
TEST_DIR  = os.path.join(DATA_ROOT, "test")
os.makedirs(TRAIN_DIR, exist_ok=True)
os.makedirs(TEST_DIR, exist_ok=True)

def safe_unzip(zip_path, out_dir):
    if not os.path.isfile(zip_path):
        raise FileNotFoundError(f"Couldn't find: {zip_path}. Upload it to Colab first.")
    if any(os.scandir(out_dir)):  # Skip if already extracted
        print(f"Skip: {out_dir} already has files.")
        return
    with zipfile.ZipFile(zip_path, "r") as zf:
        zf.extractall(out_dir)
    print(f"Extracted {os.path.basename(zip_path)} -> {out_dir}")

def count_by_class(root):
    if not os.path.isdir(root):
        return {}
    classes = sorted([d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))])
    return {c: len(glob.glob(os.path.join(root, c, "*"))) for c in classes}

# === Unzip both datasets ===
safe_unzip(TRAIN_ZIP, TRAIN_DIR)
safe_unzip(TEST_ZIP, TEST_DIR)

# === Quick sanity check ===
# actual paths after unzip
TRAIN_DIR = "/content/data_structures/train/Stuctures_Dataset"
TEST_DIR  = "/content/data_structures/test/Dataset_test/Dataset_test_original_1478"

def count_by_class(root):
    classes = sorted([d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))])
    return {c: len(glob.glob(os.path.join(root, c, "*"))) for c in classes}

print("Train set counts:")
print(count_by_class(TRAIN_DIR))

print("\nTest set counts:")
print(count_by_class(TEST_DIR))
Extracted Stuctures_Dataset.zip -> /content/data_structures/train
Extracted Dataset_test.zip -> /content/data_structures/test
Train set counts:
{'altar': 829, 'apse': 514, 'bell_tower': 1059, 'column': 1919, 'dome(inner)': 616, 'dome(outer)': 1177, 'flying_buttress': 407, 'gargoyle': 1571, 'stained_glass': 1033, 'vault': 1110}

Test set counts:
{'altar': 140, 'apse': 57, 'bell_tower': 171, 'column': 210, 'dome(inner)': 86, 'dome(outer)': 168, 'flying_buttress': 78, 'gargoyle': 240, 'stained_glass': 163, 'vault': 164}
In [ ]:
# ==== Point 1: Plot 8–10 sample images per class ====
import os, glob, random, math
import cv2
import matplotlib.pyplot as plt

# Your actual folders (post-unzip)
TRAIN_DIR = "/content/data_structures/train/Stuctures_Dataset"
TEST_DIR  = "/content/data_structures/test/Dataset_test/Dataset_test_original_1478"

# Config
SAMPLES_PER_CLASS = 10          # set to 8–10 as required
IMG_SIZE = (224, 224)           # display size (not training preprocessing)
RANDOM_SEED = 42
random.seed(RANDOM_SEED)

# Utility: list class folders
def list_classes(root):
    return sorted([d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))])

# Utility: gather image paths for a class (common extensions)
IMG_EXT = (".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp", ".JPG", ".PNG")
def get_image_paths(folder):
    return [p for p in glob.glob(os.path.join(folder, "*")) if p.endswith(IMG_EXT)]

# Utility: read image with OpenCV (BGR->RGB) + optional resize
def read_rgb(path, target_size=None):
    img = cv2.imread(path)
    if img is None:
        raise ValueError(f"Failed to read: {path}")
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    if target_size is not None:
        img = cv2.resize(img, target_size, interpolation=cv2.INTER_AREA)
    return img

# Helper to plot a grid of images for a single class
def plot_class_samples(class_name, class_dir, samples=10, img_size=(224,224)):
    paths = get_image_paths(class_dir)
    if not paths:
        print(f"[WARN] No images found for class '{class_name}' in {class_dir}")
        return
    k = min(samples, len(paths))
    sel = random.sample(paths, k)

    cols = 5 if k > 5 else k
    rows = math.ceil(k / cols)

    plt.figure(figsize=(3*cols, 3*rows))
    plt.suptitle(f"Class: {class_name}  |  showing {k} sample(s)", fontsize=14)
    for i, p in enumerate(sel, 1):
        img = read_rgb(p, target_size=img_size)
        plt.subplot(rows, cols, i)
        plt.imshow(img)
        plt.title(os.path.basename(p)[:30], fontsize=8)
        plt.axis("off")
    plt.tight_layout(rect=[0, 0, 1, 0.95])
    plt.show()

# --- Sanity: list classes and counts (train + test) ---
def count_by_class(root):
    classes = list_classes(root)
    return {c: len(get_image_paths(os.path.join(root, c))) for c in classes}

print("Train classes & counts:")
train_classes = list_classes(TRAIN_DIR)
print({c: count_by_class(TRAIN_DIR)[c] for c in train_classes})

print("\nTest classes & counts:")
test_classes = list_classes(TEST_DIR)
print({c: count_by_class(TEST_DIR)[c] for c in test_classes})

# --- Plot 8–10 samples per class for TRAIN set ---
for cls in train_classes:
    plot_class_samples(cls, os.path.join(TRAIN_DIR, cls),
                       samples=SAMPLES_PER_CLASS, img_size=IMG_SIZE)

#visualize TEST set samples per class
for cls in test_classes:
  plot_class_samples(cls, os.path.join(TEST_DIR, cls),
                     samples=SAMPLES_PER_CLASS, img_size=IMG_SIZE)
Train classes & counts:
{'altar': 829, 'apse': 514, 'bell_tower': 1059, 'column': 1919, 'dome(inner)': 616, 'dome(outer)': 1177, 'flying_buttress': 407, 'gargoyle': 1571, 'stained_glass': 1033, 'vault': 1110}

Test classes & counts:
{'altar': 140, 'apse': 57, 'bell_tower': 170, 'column': 210, 'dome(inner)': 86, 'dome(outer)': 168, 'flying_buttress': 78, 'gargoyle': 240, 'stained_glass': 163, 'vault': 164}
In [24]:
# ==== Point 2: Select CNN + configure transfer learning (load pre-trained weights) ====
import os, glob
import tensorflow as tf

# Use your actual folders (post-unzip)
TRAIN_DIR = "/content/data_structures/train/Stuctures_Dataset"
TEST_DIR  = "/content/data_structures/test/Dataset_test/Dataset_test_original_1478"

# ---- Discover classes from train dir ----
CLASS_NAMES = sorted([d for d in os.listdir(TRAIN_DIR) if os.path.isdir(os.path.join(TRAIN_DIR, d))])
NUM_CLASSES = len(CLASS_NAMES)
print(f"Detected {NUM_CLASSES} classes:\n{CLASS_NAMES}")

# ---- Hyperparams / image size ----
IMG_SIZE = 224   # EfficientNetB0 default
INPUT_SHAPE = (IMG_SIZE, IMG_SIZE, 3)

# ---- Choose backbone: EfficientNetB0 ----
from tensorflow.keras import layers, Model
from tensorflow.keras.applications import efficientnet

base_model = tf.keras.applications.EfficientNetB0(
    include_top=False,
    weights="imagenet",
    input_shape=INPUT_SHAPE,
    pooling="avg"   # global average pooling so we can attach a dense head next
)

# Freeze all convolutional layers (initial transfer-learning phase)
base_model.trainable = False

# Prepare an input -> base -> pooled output graph we will extend in Point 4
inputs = layers.Input(shape=INPUT_SHAPE, name="image")
# EfficientNet expects pixels scaled to [0,255] then internally rescales; use its preprocess if you build pipelines later
x = base_model(inputs, training=False)  # important: pass training=False when base is frozen
# (No classification head yet; we’ll add Dense/Dropout in Point 4)
backbone_output = x

# Create a temporary model object to inspect shapes; we'll finalize in Point 4
backbone_model = Model(inputs, backbone_output, name="efficientnetb0_backbone_frozen")
backbone_model.summary(line_length=120)

print("\nBackbone ready. Next (Point 4): add Dense layers + Dropout head for", NUM_CLASSES, "classes.")
Detected 10 classes:
['altar', 'apse', 'bell_tower', 'column', 'dome(inner)', 'dome(outer)', 'flying_buttress', 'gargoyle', 'stained_glass', 'vault']
Model: "efficientnetb0_backbone_frozen"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━
┃ Layer (type)                                        ┃ Output Shape                           ┃               Para
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━
│ image (InputLayer)                                  │ (None, 224, 224, 3)                    │                   
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ efficientnetb0 (Functional)                         │ (None, 1280)                           │             4,049,
└─────────────────────────────────────────────────────┴────────────────────────────────────────┴───────────────────
 Total params: 4,049,571 (15.45 MB)
 Trainable params: 0 (0.00 B)
 Non-trainable params: 4,049,571 (15.45 MB)
Backbone ready. Next (Point 4): add Dense layers + Dropout head for 10 classes.
In [25]:
# ==== Point 3: Freeze all conv layers (transfer learning) & prep fine-tune utility ====
import tensorflow as tf
from tensorflow.keras import layers, Model

# If you ran Point 2, base_model/backbone_model already exist. If not, rebuild minimal backbone.
if 'base_model' not in globals():
    INPUT_SHAPE = (224, 224, 3)
    base_model = tf.keras.applications.EfficientNetB0(
        include_top=False, weights="imagenet", input_shape=INPUT_SHAPE, pooling="avg"
    )
    print("[Info] Rebuilt base_model because it wasn't found in the session.")

# 1) Freeze the entire convolutional base
for layer in base_model.layers:
    layer.trainable = False

# 2) Recreate backbone graph if missing
if 'backbone_model' not in globals():
    inputs = layers.Input(shape=base_model.input_shape[1:], name="image")
    x = base_model(inputs, training=False)   # keep base in inference mode while frozen
    backbone_model = Model(inputs, x, name="efficientnetb0_backbone_frozen")

# 3) Sanity check: report trainable vs. frozen layers
total_layers = len(base_model.layers)
trainable_layers = sum(int(l.trainable) for l in base_model.layers)
print(f"Backbone layers: {total_layers} | trainable: {trainable_layers} | frozen: {total_layers - trainable_layers}")

# 4) Utility for later fine-tuning (do NOT run now; use after initial head training)
def set_finetune_from(layer_name=None, unfreeze_blocks=0):
    """
    Unfreeze part of the backbone for fine-tuning AFTER training the head.
    - layer_name: unfreezes from this layer (inclusive) to the end.
    - unfreeze_blocks: unfreezes the last N 'block' groups (EfficientNet naming).
    """
    # Refreeze everything first
    for l in base_model.layers:
        l.trainable = False

    if layer_name:
        start = False
        for l in base_model.layers:
            if l.name == layer_name:
                start = True
            if start:
                l.trainable = True
    elif unfreeze_blocks > 0:
        block_idxs = [i for i, l in enumerate(base_model.layers) if 'block' in l.name]
        if block_idxs:
            cutoff = block_idxs[-unfreeze_blocks] if unfreeze_blocks <= len(block_idxs) else block_idxs[0]
            for i, l in enumerate(base_model.layers):
                if i >= cutoff:
                    l.trainable = True

    tr = sum(int(l.trainable) for l in base_model.layers)
    print(f"[set_finetune_from] trainable layers now: {tr}/{len(base_model.layers)}")

# Example for later (commented out):
# set_finetune_from(unfreeze_blocks=2)  # unfreeze last 2 blocks for fine-tuning
Backbone layers: 239 | trainable: 0 | frozen: 239
In [26]:
# ==== Point 4: Add classification head (Dense + Dropout) ====
import tensorflow as tf
from tensorflow.keras import layers, Model, regularizers

# Assumes these exist from Points 2–3:
# - base_model (EfficientNetB0 include_top=False, pooling="avg", trainable=False)
# - NUM_CLASSES (your 10 classes)
# - INPUT_SHAPE = (224, 224, 3)

assert 'base_model' in globals(), "Run Point 2 first to create base_model."
assert 'NUM_CLASSES' in globals(), "NUM_CLASSES missing. Re-run Point 2 to detect classes."
assert 'INPUT_SHAPE' in globals(), "INPUT_SHAPE missing. Re-run Point 2."

# Build the full model
inputs = layers.Input(shape=INPUT_SHAPE, name="image")
x = base_model(inputs, training=False)             # keep base in inference mode while frozen
x = layers.BatchNormalization(name="bn_backbone")(x)

# Classification head (tunable hyperparams)
x = layers.Dense(512, activation="relu",
                 kernel_regularizer=regularizers.l2(1e-4),
                 name="dense_512")(x)
x = layers.BatchNormalization(name="bn_1")(x)
x = layers.Dropout(0.4, name="dropout_1")(x)

x = layers.Dense(128, activation="relu",
                 kernel_regularizer=regularizers.l2(1e-4),
                 name="dense_128")(x)
x = layers.BatchNormalization(name="bn_2")(x)
x = layers.Dropout(0.3, name="dropout_2")(x)

outputs = layers.Dense(NUM_CLASSES, activation="softmax", name="predictions")(x)

model = Model(inputs, outputs, name="efficientnetb0_classifier")

# Double-check base is still frozen
for l in base_model.layers:
    l.trainable = False

model.summary(line_length=120)
Model: "efficientnetb0_classifier"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━
┃ Layer (type)                                        ┃ Output Shape                           ┃               Para
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━
│ image (InputLayer)                                  │ (None, 224, 224, 3)                    │                   
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ efficientnetb0 (Functional)                         │ (None, 1280)                           │             4,049,
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ bn_backbone (BatchNormalization)                    │ (None, 1280)                           │                 5,
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ dense_512 (Dense)                                   │ (None, 512)                            │               655,
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ bn_1 (BatchNormalization)                           │ (None, 512)                            │                 2,
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ dropout_1 (Dropout)                                 │ (None, 512)                            │                   
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ dense_128 (Dense)                                   │ (None, 128)                            │                65,
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ bn_2 (BatchNormalization)                           │ (None, 128)                            │                   
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ dropout_2 (Dropout)                                 │ (None, 128)                            │                   
├─────────────────────────────────────────────────────┼────────────────────────────────────────┼───────────────────
│ predictions (Dense)                                 │ (None, 10)                             │                 1,
└─────────────────────────────────────────────────────┴────────────────────────────────────────┴───────────────────
 Total params: 4,780,077 (18.23 MB)
 Trainable params: 726,666 (2.77 MB)
 Non-trainable params: 4,053,411 (15.46 MB)
In [27]:
# ==== Point 5: Compile the model ====
import tensorflow as tf
from tensorflow.keras import optimizers, losses, metrics
import numpy as np # Import numpy

assert 'model' in globals(), "Run Point 4 first to build the model."

# Optimizer: slightly higher LR while backbone is frozen (we're training the head)
initial_lr = 1e-3
opt = optimizers.Adam(learning_rate=initial_lr)

# Loss/metrics: for one-hot labels (class_mode='categorical' in generators)
model.compile(
    optimizer=opt,
    loss=losses.CategoricalCrossentropy(label_smoothing=0.0),
    metrics=[
        metrics.CategoricalAccuracy(name="accuracy"),
        metrics.TopKCategoricalAccuracy(k=3, name="top3_acc"),
    ],
)

# Quick sanity: count trainable vs total parameters
trainable_count = np.sum([np.prod(v.shape) for v in model.trainable_variables])
total_count = np.sum([np.prod(v.shape) for v in model.variables])
print(f"Compiled. Trainable params: {trainable_count:,} / Total params: {total_count:,}")
print("Next (Point 6): define a callback to stop when val_accuracy reaches your target.")
Compiled. Trainable params: 726,666 / Total params: 4,780,099.0
Next (Point 6): define a callback to stop when val_accuracy reaches your target.
In [28]:
# ==== Point 6: Early stopping when validation accuracy hits target ====
import tensorflow as tf

class ValAccThresholdCallback(tf.keras.callbacks.Callback):
    def __init__(self, threshold=0.95):
        super().__init__()
        self.threshold = threshold

    def on_epoch_end(self, epoch, logs=None):
        logs = logs or {}
        val_acc = logs.get("val_accuracy")
        if val_acc is not None and val_acc >= self.threshold:
            print(f"\n[INFO] Reached {val_acc:.4f} val_accuracy — stopping training.")
            self.model.stop_training = True

# Example: stop if val_accuracy ≥ 92%
val_acc_cb = ValAccThresholdCallback(threshold=0.92)

# You can combine with EarlyStopping for patience-based stopping:
early_stop_cb = tf.keras.callbacks.EarlyStopping(
    monitor="val_accuracy",
    patience=3,
    restore_best_weights=True
)

print("Callbacks ready: val_acc_cb (threshold) and early_stop_cb (patience)")
Callbacks ready: val_acc_cb (threshold) and early_stop_cb (patience)
In [29]:
# ==== Point 7: Setup datasets & review sample counts ====
# --- Scan for corrupted/non-decodable images and quarantine them ---
import os, shutil, pathlib
from PIL import Image

TRAIN_DIR = "/content/data_structures/train/Stuctures_Dataset"
TEST_DIR  = "/content/data_structures/test/Dataset_test/Dataset_test_original_1478"
QUAR_DIR  = "/content/bad_images_quarantine"
os.makedirs(QUAR_DIR, exist_ok=True)

VALID_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp", ".JPG", ".PNG"}

bad_files = []

def scan_and_quarantine(root):
    for cls in sorted([d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))]):
        cls_folder = os.path.join(root, cls)
        for fname in os.listdir(cls_folder):
            src = os.path.join(cls_folder, fname)
            if not os.path.isfile(src):
                continue
            if pathlib.Path(src).suffix not in VALID_EXTS:
                bad_files.append(src)
                continue
            try:
                with Image.open(src) as im:
                    im.verify()  # quick integrity check
                # Reopen to ensure load() works too (some files pass verify but fail load)
                with Image.open(src) as im:
                    im.load()
            except Exception:
                bad_files.append(src)

    # Move bad files (keep class subfolders)
    for i, src in enumerate(bad_files, 1):
        rel = os.path.relpath(src, root)
        dst = os.path.join(QUAR_DIR, rel)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        try:
            shutil.move(src, dst)
        except Exception:
            # If move fails (e.g., cross-device), copy then remove
            shutil.copy2(src, dst)
            os.remove(src)

    return len(bad_files)

n_train_bad = scan_and_quarantine(TRAIN_DIR)
n_test_bad  = scan_and_quarantine(TEST_DIR)

print(f"Quarantined {n_train_bad} bad files from TRAIN and {n_test_bad} from TEST.")
print(f"Quarantine folder: {QUAR_DIR}")

# --- Recreate TF datasets after cleaning ---
import tensorflow as tf
import os

IMG_SIZE = (224, 224)
BATCH_SIZE = 32
AUTOTUNE = tf.data.AUTOTUNE
preprocess = tf.keras.applications.efficientnet.preprocess_input

def build_ds(root, shuffle, batch_size=BATCH_SIZE):
    ds = tf.keras.utils.image_dataset_from_directory(
        root,
        image_size=IMG_SIZE,
        batch_size=batch_size,
        label_mode="categorical",
        shuffle=shuffle,
        seed=42
    )
    ds = ds.map(lambda x,y: (preprocess(tf.cast(x, tf.float32)), y),
                num_parallel_calls=AUTOTUNE)
    return ds.cache().prefetch(AUTOTUNE)

train_ds = build_ds("/content/data_structures/train/Stuctures_Dataset", shuffle=True)
val_ds   = build_ds("/content/data_structures/test/Dataset_test/Dataset_test_original_1478", shuffle=False)

print("Datasets rebuilt.")
Quarantined 0 bad files from TRAIN and 4 from TEST.
Quarantine folder: /content/bad_images_quarantine
Found 10235 files belonging to 10 classes.
Found 1473 files belonging to 10 classes.
Datasets rebuilt.
In [30]:
# ==== Point 8: Train the model (NO augmentation) ====
import tensorflow as tf
from tensorflow.keras.callbacks import ModelCheckpoint, ReduceLROnPlateau

assert 'model' in globals(), "Run Points 4–5 first."
assert 'train_ds' in globals() and 'val_ds' in globals(), "Run the UPDATED Point 7 (with preprocess)."
assert 'val_acc_cb' in globals() and 'early_stop_cb' in globals(), "Run Point 6 for callbacks."

ckpt_path = "/content/efficientnetb0_noaug_best.keras"
ckpt_cb = ModelCheckpoint(
    ckpt_path, monitor="val_accuracy", save_best_only=True, mode="max", verbose=1
)

rlr_cb = ReduceLROnPlateau(
    monitor="val_accuracy", factor=0.5, patience=2, min_lr=1e-6, verbose=1
)

EPOCHS = 20  # adjust if needed

history_noaug = model.fit(
    train_ds,                 # already preprocessed in Point 7
    validation_data=val_ds,   # already preprocessed in Point 7
    epochs=EPOCHS,
    callbacks=[rlr_cb, ckpt_cb],
    verbose=1
)

print("Training (no augmentation) finished. Best model saved to:", ckpt_path)
Epoch 1/20
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 94ms/step - accuracy: 0.8333 - loss: 0.6466 - top3_acc: 0.9366
Epoch 1: val_accuracy improved from -inf to 0.94773, saving model to /content/efficientnetb0_noaug_best.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 73s 141ms/step - accuracy: 0.8335 - loss: 0.6459 - top3_acc: 0.9367 - val_accuracy: 0.9477 - val_loss: 0.2771 - val_top3_acc: 0.9905 - learning_rate: 0.0010
Epoch 2/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 33ms/step - accuracy: 0.9541 - loss: 0.2439 - top3_acc: 0.9977
Epoch 2: val_accuracy did not improve from 0.94773
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 37ms/step - accuracy: 0.9541 - loss: 0.2439 - top3_acc: 0.9977 - val_accuracy: 0.9470 - val_loss: 0.2762 - val_top3_acc: 0.9952 - learning_rate: 0.0010
Epoch 3/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9638 - loss: 0.2117 - top3_acc: 0.9973
Epoch 3: val_accuracy improved from 0.94773 to 0.95384, saving model to /content/efficientnetb0_noaug_best.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 13s 40ms/step - accuracy: 0.9638 - loss: 0.2116 - top3_acc: 0.9973 - val_accuracy: 0.9538 - val_loss: 0.2622 - val_top3_acc: 0.9946 - learning_rate: 0.0010
Epoch 4/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9729 - loss: 0.1919 - top3_acc: 0.9989
Epoch 4: val_accuracy did not improve from 0.95384
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 36ms/step - accuracy: 0.9729 - loss: 0.1919 - top3_acc: 0.9989 - val_accuracy: 0.9525 - val_loss: 0.2855 - val_top3_acc: 0.9952 - learning_rate: 0.0010
Epoch 5/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9813 - loss: 0.1660 - top3_acc: 0.9997
Epoch 5: val_accuracy improved from 0.95384 to 0.95519, saving model to /content/efficientnetb0_noaug_best.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 38ms/step - accuracy: 0.9813 - loss: 0.1660 - top3_acc: 0.9997 - val_accuracy: 0.9552 - val_loss: 0.2853 - val_top3_acc: 0.9973 - learning_rate: 0.0010
Epoch 6/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9787 - loss: 0.1676 - top3_acc: 0.9998
Epoch 6: val_accuracy did not improve from 0.95519
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 36ms/step - accuracy: 0.9787 - loss: 0.1676 - top3_acc: 0.9998 - val_accuracy: 0.9552 - val_loss: 0.2881 - val_top3_acc: 0.9959 - learning_rate: 0.0010
Epoch 7/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9842 - loss: 0.1590 - top3_acc: 0.9995
Epoch 7: ReduceLROnPlateau reducing learning rate to 0.0005000000237487257.

Epoch 7: val_accuracy did not improve from 0.95519
320/320 ━━━━━━━━━━━━━━━━━━━━ 20s 36ms/step - accuracy: 0.9842 - loss: 0.1590 - top3_acc: 0.9995 - val_accuracy: 0.9504 - val_loss: 0.2869 - val_top3_acc: 0.9946 - learning_rate: 0.0010
Epoch 8/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9903 - loss: 0.1416 - top3_acc: 0.9999
Epoch 8: val_accuracy improved from 0.95519 to 0.96130, saving model to /content/efficientnetb0_noaug_best.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 39ms/step - accuracy: 0.9903 - loss: 0.1416 - top3_acc: 0.9999 - val_accuracy: 0.9613 - val_loss: 0.2625 - val_top3_acc: 0.9966 - learning_rate: 5.0000e-04
Epoch 9/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9928 - loss: 0.1297 - top3_acc: 0.9998
Epoch 9: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 20s 37ms/step - accuracy: 0.9928 - loss: 0.1297 - top3_acc: 0.9998 - val_accuracy: 0.9545 - val_loss: 0.2876 - val_top3_acc: 0.9959 - learning_rate: 5.0000e-04
Epoch 10/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9944 - loss: 0.1233 - top3_acc: 1.0000
Epoch 10: ReduceLROnPlateau reducing learning rate to 0.0002500000118743628.

Epoch 10: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 21s 40ms/step - accuracy: 0.9944 - loss: 0.1233 - top3_acc: 1.0000 - val_accuracy: 0.9477 - val_loss: 0.3195 - val_top3_acc: 0.9919 - learning_rate: 5.0000e-04
Epoch 11/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9948 - loss: 0.1191 - top3_acc: 1.0000
Epoch 11: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 13s 40ms/step - accuracy: 0.9948 - loss: 0.1191 - top3_acc: 1.0000 - val_accuracy: 0.9538 - val_loss: 0.2949 - val_top3_acc: 0.9946 - learning_rate: 2.5000e-04
Epoch 12/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9965 - loss: 0.1105 - top3_acc: 0.9996
Epoch 12: ReduceLROnPlateau reducing learning rate to 0.0001250000059371814.

Epoch 12: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 36ms/step - accuracy: 0.9965 - loss: 0.1105 - top3_acc: 0.9996 - val_accuracy: 0.9579 - val_loss: 0.2834 - val_top3_acc: 0.9966 - learning_rate: 2.5000e-04
Epoch 13/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9959 - loss: 0.1070 - top3_acc: 0.9999
Epoch 13: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 36ms/step - accuracy: 0.9959 - loss: 0.1070 - top3_acc: 0.9999 - val_accuracy: 0.9586 - val_loss: 0.2904 - val_top3_acc: 0.9946 - learning_rate: 1.2500e-04
Epoch 14/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 31ms/step - accuracy: 0.9979 - loss: 0.1022 - top3_acc: 1.0000
Epoch 14: ReduceLROnPlateau reducing learning rate to 6.25000029685907e-05.

Epoch 14: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 13s 40ms/step - accuracy: 0.9979 - loss: 0.1022 - top3_acc: 1.0000 - val_accuracy: 0.9599 - val_loss: 0.2859 - val_top3_acc: 0.9959 - learning_rate: 1.2500e-04
Epoch 15/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9987 - loss: 0.0985 - top3_acc: 1.0000
Epoch 15: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 36ms/step - accuracy: 0.9987 - loss: 0.0986 - top3_acc: 1.0000 - val_accuracy: 0.9593 - val_loss: 0.2893 - val_top3_acc: 0.9966 - learning_rate: 6.2500e-05
Epoch 16/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9983 - loss: 0.0978 - top3_acc: 1.0000
Epoch 16: ReduceLROnPlateau reducing learning rate to 3.125000148429535e-05.

Epoch 16: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 13s 40ms/step - accuracy: 0.9983 - loss: 0.0978 - top3_acc: 1.0000 - val_accuracy: 0.9572 - val_loss: 0.2899 - val_top3_acc: 0.9952 - learning_rate: 6.2500e-05
Epoch 17/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9974 - loss: 0.0982 - top3_acc: 1.0000
Epoch 17: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 20s 37ms/step - accuracy: 0.9974 - loss: 0.0982 - top3_acc: 1.0000 - val_accuracy: 0.9559 - val_loss: 0.2912 - val_top3_acc: 0.9952 - learning_rate: 3.1250e-05
Epoch 18/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 33ms/step - accuracy: 0.9977 - loss: 0.0982 - top3_acc: 1.0000
Epoch 18: ReduceLROnPlateau reducing learning rate to 1.5625000742147677e-05.

Epoch 18: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 21s 37ms/step - accuracy: 0.9977 - loss: 0.0982 - top3_acc: 1.0000 - val_accuracy: 0.9606 - val_loss: 0.2820 - val_top3_acc: 0.9959 - learning_rate: 3.1250e-05
Epoch 19/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9988 - loss: 0.0948 - top3_acc: 1.0000
Epoch 19: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 37ms/step - accuracy: 0.9988 - loss: 0.0948 - top3_acc: 1.0000 - val_accuracy: 0.9593 - val_loss: 0.2834 - val_top3_acc: 0.9952 - learning_rate: 1.5625e-05
Epoch 20/20
319/320 ━━━━━━━━━━━━━━━━━━━━ 0s 32ms/step - accuracy: 0.9991 - loss: 0.0939 - top3_acc: 1.0000
Epoch 20: ReduceLROnPlateau reducing learning rate to 7.812500371073838e-06.

Epoch 20: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 12s 36ms/step - accuracy: 0.9991 - loss: 0.0939 - top3_acc: 1.0000 - val_accuracy: 0.9593 - val_loss: 0.2824 - val_top3_acc: 0.9952 - learning_rate: 1.5625e-05
Training (no augmentation) finished. Best model saved to: /content/efficientnetb0_noaug_best.keras
In [31]:
#Do this if runtime restarted
# Detect classes (train dir after unzip)
import os
TRAIN_DIR = "/content/data_structures/train/Stuctures_Dataset"
CLASS_NAMES = sorted([d for d in os.listdir(TRAIN_DIR) if os.path.isdir(os.path.join(TRAIN_DIR, d))])
NUM_CLASSES = len(CLASS_NAMES)
INPUT_SHAPE = (224, 224, 3)
print(NUM_CLASSES, "classes:", CLASS_NAMES)

# Backbone (EfficientNetB0 frozen)
import tensorflow as tf
from tensorflow.keras import layers, Model, regularizers

base_model = tf.keras.applications.EfficientNetB0(
    include_top=False, weights="imagenet", input_shape=INPUT_SHAPE, pooling="avg"
)
base_model.trainable = False

# Head
inputs = layers.Input(shape=INPUT_SHAPE, name="image")
x = base_model(inputs, training=False)
x = layers.BatchNormalization()(x)
x = layers.Dense(512, activation="relu", kernel_regularizer=regularizers.l2(1e-4))(x)
x = layers.BatchNormalization()(x)
x = layers.Dropout(0.4)(x)
x = layers.Dense(128, activation="relu", kernel_regularizer=regularizers.l2(1e-4))(x)
x = layers.BatchNormalization()(x)
x = layers.Dropout(0.3)(x)
outputs = layers.Dense(NUM_CLASSES, activation="softmax")(x)

model = Model(inputs, outputs)

# Compile
model.compile(
    optimizer=tf.keras.optimizers.Adam(1e-3),
    loss="categorical_crossentropy",
    metrics=["accuracy", tf.keras.metrics.TopKCategoricalAccuracy(k=3, name="top3_acc")]
)

# Load your best no-augmentation weights
model.load_weights("/content/efficientnetb0_noaug_best.keras")
10 classes: ['altar', 'apse', 'bell_tower', 'column', 'dome(inner)', 'dome(outer)', 'flying_buttress', 'gargoyle', 'stained_glass', 'vault']
/usr/local/lib/python3.11/dist-packages/keras/src/saving/saving_lib.py:802: UserWarning: Skipping variable loading for optimizer 'adam', because it has 2 variables whereas the saved optimizer has 26 variables. 
  saveable.load_own_variables(weights_store.get(inner_path))
In [1]:
# === Point 10 recovery (no `classes=` argument version) ===
# === RESET & DIAGNOSE, then AUG TRAIN (robust to class-order differences) ===
import os, tensorflow as tf
from tensorflow.keras import layers, Model
from tensorflow.keras.callbacks import ModelCheckpoint, ReduceLROnPlateau

# Paths
TRAIN_DIR = "/content/data_structures/train/Stuctures_Dataset"
VAL_DIR   = "/content/data_structures/test/Dataset_test/Dataset_test_original_1478"
CKPT_NOAUG = "/content/efficientnetb0_noaug_best.keras"    # good baseline
CKPT_AUG   = "/content/efficientnetb0_aug_best_fixed.keras" # new save

assert os.path.exists(CKPT_NOAUG), "Missing baseline checkpoint at " + CKPT_NOAUG

# 0) Load the baseline FULL model (the classifier WITHOUT aug wrapper)
baseline = tf.keras.models.load_model(CKPT_NOAUG, compile=False)

# Recompile (same loss/metrics shape as before)
baseline.compile(
    optimizer=tf.keras.optimizers.Adam(1e-3),
    loss="categorical_crossentropy",
    metrics=[tf.keras.metrics.CategoricalAccuracy(name="accuracy"),
             tf.keras.metrics.TopKCategoricalAccuracy(k=3, name="top3_acc")]
)

# 1) Build RAW val dataset
IMG_SIZE = (224, 224)
BATCH_SIZE = 32
val_raw = tf.keras.utils.image_dataset_from_directory(
    VAL_DIR, image_size=IMG_SIZE, batch_size=BATCH_SIZE,
    label_mode="categorical", shuffle=False
)
val_classes = val_raw.class_names
print("Val classes:", val_classes)

# 2) Apply the SAME preprocessing as your successful no-aug run
#    (We preprocessed in the dataset pipeline then fed into `baseline`.)
AUTOTUNE = tf.data.AUTOTUNE
preprocess = tf.keras.applications.efficientnet.preprocess_input

val_prep = val_raw.map(lambda x,y: (preprocess(tf.cast(x, tf.float32)), y),
                       num_parallel_calls=AUTOTUNE).cache().prefetch(AUTOTUNE)

# 3) SANITY CHECK: evaluate baseline on current val
print("Evaluating baseline no-aug model on current val set...")
b_loss, b_acc, b_top3 = baseline.evaluate(val_prep, verbose=0)
print(f"[Baseline sanity] val_acc={b_acc:.4f}, top3={b_top3:.4f}")

# If baseline sanity is LOW (<0.8), labels might be misaligned due to class-order differences
# We'll rebuild TRAIN *and* VAL and explicitly remap labels so both align with TRAIN order.

def build_and_align(train_dir, val_dir, batch=32):
    train_raw = tf.keras.utils.image_dataset_from_directory(
        train_dir, image_size=IMG_SIZE, batch_size=batch,
        label_mode="categorical", shuffle=True, seed=42
    )
    val_raw  = tf.keras.utils.image_dataset_from_directory(
        val_dir, image_size=IMG_SIZE, batch_size=batch,
        label_mode="categorical", shuffle=False
    )
    train_classes = train_raw.class_names
    val_classes = val_raw.class_names
    print("Train classes:", train_classes)
    print("Val classes:  ", val_classes)

    # Build permutation to map VAL labels -> TRAIN order (identity if equal)
    # y is one-hot in val/train. We want both to be TRAIN order for the model head.
    import numpy as np
    perm = []
    for cls in train_classes:
        try:
            perm.append(val_classes.index(cls))
        except ValueError:
            raise ValueError(f"Class {cls} missing from VAL set.")
    perm = tf.constant(perm, dtype=tf.int32)

    def align_to_train_order(y):
        # y is one-hot in VAL order; gather to TRAIN order
        return tf.gather(y, perm, axis=-1)

    preprocess = tf.keras.applications.efficientnet.preprocess_input
    AUTOTUNE = tf.data.AUTOTUNE

    train_prep = (train_raw
                  .map(lambda x,y: (preprocess(tf.cast(x, tf.float32)), y), num_parallel_calls=AUTOTUNE)
                  .cache().prefetch(AUTOTUNE))

    val_prep = (val_raw
                .map(lambda x,y: (preprocess(tf.cast(x, tf.float32)), align_to_train_order(y)),
                     num_parallel_calls=AUTOTUNE)
                .cache().prefetch(AUTOTUNE))
    return train_prep, val_prep, train_classes

# If sanity low, realign; if sanity good, still rebuild train with identical preprocessing for aug wrapper
realign_needed = b_acc < 0.80

train_ds_prep, val_ds_prep, TRAIN_CLASSES = build_and_align(TRAIN_DIR, VAL_DIR, batch=32)

# 4) Gentle augmentation INSIDE the model graph, then SAME preprocess (to be identical)
augmentation = tf.keras.Sequential([
    layers.RandomFlip("horizontal"),
    layers.RandomRotation(0.05),
    layers.RandomZoom(0.10),
    layers.RandomTranslation(0.03, 0.03),
    layers.RandomContrast(0.10),
], name="augmentation")

# Build an aug wrapper: inputs -> aug -> preprocess -> baseline
inp = layers.Input(shape=IMG_SIZE + (3,), name="raw_images")
x   = augmentation(inp, training=True)
x   = layers.Lambda(lambda t: preprocess(tf.cast(t, tf.float32)), name="efnet_preprocess")(x)
out = baseline(x)
aug_model = Model(inp, out, name="aug_wrapper")

# Lower LR to avoid forgetting your good head weights
aug_model.compile(
    optimizer=tf.keras.optimizers.Adam(1e-4),
    loss="categorical_crossentropy",
    metrics=[tf.keras.metrics.CategoricalAccuracy(name="accuracy"),
             tf.keras.metrics.TopKCategoricalAccuracy(k=3, name="top3_acc")]
)

ckpt_cb = ModelCheckpoint(CKPT_AUG, monitor="val_accuracy",
                          save_best_only=True, mode="max", verbose=1, save_weights_only=False)
rlr_cb  = ReduceLROnPlateau(monitor="val_accuracy", factor=0.5, patience=2, min_lr=1e-6, verbose=1)

print("Starting short augmented training (8 epochs) with aligned labels & identical preprocessing...")
hist = aug_model.fit(
    train_ds_prep,
    validation_data=val_ds_prep,
    epochs=8,
    callbacks=[rlr_cb, ckpt_cb],
    verbose=1
)

print("Done. Best augmented model saved to:", CKPT_AUG)
Found 1473 files belonging to 10 classes.
Val classes: ['altar', 'apse', 'bell_tower', 'column', 'dome(inner)', 'dome(outer)', 'flying_buttress', 'gargoyle', 'stained_glass', 'vault']
Evaluating baseline no-aug model on current val set...
[Baseline sanity] val_acc=0.9613, top3=0.9966
Found 10235 files belonging to 10 classes.
Found 1473 files belonging to 10 classes.
Train classes: ['altar', 'apse', 'bell_tower', 'column', 'dome(inner)', 'dome(outer)', 'flying_buttress', 'gargoyle', 'stained_glass', 'vault']
Val classes:   ['altar', 'apse', 'bell_tower', 'column', 'dome(inner)', 'dome(outer)', 'flying_buttress', 'gargoyle', 'stained_glass', 'vault']
Starting short augmented training (8 epochs) with aligned labels & identical preprocessing...
Epoch 1/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 91ms/step - accuracy: 0.9655 - loss: 0.2129 - top3_acc: 0.9982
Epoch 1: val_accuracy improved from -inf to 0.95587, saving model to /content/efficientnetb0_aug_best_fixed.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 55s 118ms/step - accuracy: 0.9655 - loss: 0.2129 - top3_acc: 0.9982 - val_accuracy: 0.9559 - val_loss: 0.2890 - val_top3_acc: 0.9939 - learning_rate: 1.0000e-04
Epoch 2/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 77ms/step - accuracy: 0.9698 - loss: 0.1925 - top3_acc: 0.9987
Epoch 2: val_accuracy improved from 0.95587 to 0.95859, saving model to /content/efficientnetb0_aug_best_fixed.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 28s 89ms/step - accuracy: 0.9698 - loss: 0.1925 - top3_acc: 0.9987 - val_accuracy: 0.9586 - val_loss: 0.2869 - val_top3_acc: 0.9946 - learning_rate: 1.0000e-04
Epoch 3/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 77ms/step - accuracy: 0.9759 - loss: 0.1778 - top3_acc: 0.9992
Epoch 3: val_accuracy did not improve from 0.95859
320/320 ━━━━━━━━━━━━━━━━━━━━ 28s 87ms/step - accuracy: 0.9759 - loss: 0.1778 - top3_acc: 0.9992 - val_accuracy: 0.9586 - val_loss: 0.2872 - val_top3_acc: 0.9952 - learning_rate: 1.0000e-04
Epoch 4/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 77ms/step - accuracy: 0.9765 - loss: 0.1782 - top3_acc: 0.9985
Epoch 4: val_accuracy improved from 0.95859 to 0.96062, saving model to /content/efficientnetb0_aug_best_fixed.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 29s 89ms/step - accuracy: 0.9765 - loss: 0.1782 - top3_acc: 0.9985 - val_accuracy: 0.9606 - val_loss: 0.2849 - val_top3_acc: 0.9946 - learning_rate: 1.0000e-04
Epoch 5/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 77ms/step - accuracy: 0.9790 - loss: 0.1681 - top3_acc: 0.9988
Epoch 5: val_accuracy did not improve from 0.96062
320/320 ━━━━━━━━━━━━━━━━━━━━ 40s 87ms/step - accuracy: 0.9790 - loss: 0.1681 - top3_acc: 0.9988 - val_accuracy: 0.9593 - val_loss: 0.2905 - val_top3_acc: 0.9939 - learning_rate: 1.0000e-04
Epoch 6/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 77ms/step - accuracy: 0.9787 - loss: 0.1660 - top3_acc: 0.9997
Epoch 6: val_accuracy improved from 0.96062 to 0.96130, saving model to /content/efficientnetb0_aug_best_fixed.keras
320/320 ━━━━━━━━━━━━━━━━━━━━ 29s 89ms/step - accuracy: 0.9787 - loss: 0.1660 - top3_acc: 0.9997 - val_accuracy: 0.9613 - val_loss: 0.2789 - val_top3_acc: 0.9952 - learning_rate: 1.0000e-04
Epoch 7/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 77ms/step - accuracy: 0.9801 - loss: 0.1622 - top3_acc: 0.9993
Epoch 7: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 28s 87ms/step - accuracy: 0.9801 - loss: 0.1622 - top3_acc: 0.9993 - val_accuracy: 0.9606 - val_loss: 0.2765 - val_top3_acc: 0.9952 - learning_rate: 1.0000e-04
Epoch 8/8
320/320 ━━━━━━━━━━━━━━━━━━━━ 0s 77ms/step - accuracy: 0.9829 - loss: 0.1526 - top3_acc: 0.9995
Epoch 8: ReduceLROnPlateau reducing learning rate to 4.999999873689376e-05.

Epoch 8: val_accuracy did not improve from 0.96130
320/320 ━━━━━━━━━━━━━━━━━━━━ 28s 87ms/step - accuracy: 0.9829 - loss: 0.1526 - top3_acc: 0.9995 - val_accuracy: 0.9606 - val_loss: 0.2728 - val_top3_acc: 0.9959 - learning_rate: 1.0000e-04
Done. Best augmented model saved to: /content/efficientnetb0_aug_best_fixed.keras
In [2]:
# ==== Point 12: Plot training vs validation curves (handles no-aug & aug if available) ====
import matplotlib.pyplot as plt

def plot_history(hist, title):
    if hist is None:
        print(f"[skip] {title}: no history object found.")
        return
    H = hist.history
    # Try common key variants
    acc_keys  = [k for k in H.keys() if k in ["accuracy","categorical_accuracy"]]
    val_keys  = [k for k in H.keys() if k in ["val_accuracy","val_categorical_accuracy"]]
    loss_key  = "loss" if "loss" in H else None
    vloss_key = "val_loss" if "val_loss" in H else None
    top3_key  = "top3_acc" if "top3_acc" in H else ("top_k_categorical_accuracy" if "top_k_categorical_accuracy" in H else None)
    vtop3_key = f"val_{top3_key}" if top3_key and f"val_{top3_key}" in H else None

    # Accuracy
    if acc_keys and val_keys:
        plt.figure(figsize=(6,4))
        plt.plot(H[acc_keys[0]], label="train acc")
        plt.plot(H[val_keys[0]], label="val acc")
        plt.xlabel("Epoch"); plt.ylabel("Accuracy"); plt.title(f"{title} — Accuracy"); plt.legend(); plt.grid(True)
        plt.show()

    # Top-3 (optional)
    if top3_key and vtop3_key:
        plt.figure(figsize=(6,4))
        plt.plot(H[top3_key], label="train top3")
        plt.plot(H[vtop3_key], label="val top3")
        plt.xlabel("Epoch"); plt.ylabel("Top-3 Accuracy"); plt.title(f"{title} — Top-3"); plt.legend(); plt.grid(True)
        plt.show()

    # Loss
    if loss_key and vloss_key:
        plt.figure(figsize=(6,4))
        plt.plot(H[loss_key], label="train loss")
        plt.plot(H[vloss_key], label="val loss")
        plt.xlabel("Epoch"); plt.ylabel("Loss"); plt.title(f"{title} — Loss"); plt.legend(); plt.grid(True)
        plt.show()

# Try to plot both histories if they exist in memory
try:
    plot_history(history_noaug, "No augmentation")
except NameError:
    plot_history(None, "No augmentation")

try:
    plot_history(hist if 'hist' in globals() else history_aug, "With augmentation")
except NameError:
    plot_history(None, "With augmentation")
[skip] No augmentation: no history object found.
In [1]:
# ==== EVALUATION: confusion matrix + per-class accuracy on TEST ====
import os, numpy as np, tensorflow as tf, matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, classification_report

# ---- Paths
TRAIN_DIR = "/content/data_structures/train/Stuctures_Dataset"
TEST_DIR  = "/content/data_structures/test/Dataset_test/Dataset_test_original_1478"

CANDIDATE_CKPTS = [
    "/content/efficientnetb0_aug_best_fixed.keras",
    # older names you may have:
    "/content/efficientnetb0_aug_best.keras",
    "/content/efficientnetb0_noaug_best.keras",
]

# ---- Load model (try aug model first; fall back to no-aug). Handle Lambda safely.
def load_best_model():
    # try safe load first
    for p in CANDIDATE_CKPTS:
        if os.path.exists(p):
            try:
                m = tf.keras.models.load_model(p, compile=False)
                print("Loaded model (safe):", p)
                return m
            except Exception as e:
                print(f"Safe load failed for {p}: {e}")
                # try unsafe only if you trust your own file
                try:
                    import keras
                    keras.config.enable_unsafe_deserialization()
                    m = keras.models.load_model(p, compile=False)
                    print("Loaded model (unsafe allowed):", p)
                    return m
                except Exception as e2:
                    print(f"Unsafe load also failed for {p}: {e2}")
    raise FileNotFoundError("No usable checkpoint found in CANDIDATE_CKPTS.")

model = load_best_model()

# ---- Build small dataset to read TRAIN class order (source of truth)
IMG_SIZE = (224, 224)
BATCH = 32
train_tmp = tf.keras.utils.image_dataset_from_directory(
    TRAIN_DIR, image_size=IMG_SIZE, batch_size=BATCH,
    label_mode="categorical", shuffle=False
)
train_classes = train_tmp.class_names
num_classes = len(train_classes)
print("Train classes:", train_classes)

# ---- TEST dataset (labels may be in a different order)
test_raw = tf.keras.utils.image_dataset_from_directory(
    TEST_DIR, image_size=IMG_SIZE, batch_size=BATCH,
    label_mode="categorical", shuffle=False
)
test_classes = test_raw.class_names
print("Test classes: ", test_classes)

# ---- Align TEST one-hot labels to TRAIN order
perm = tf.constant([test_classes.index(c) for c in train_classes], dtype=tf.int32)
def align_to_train_order(y):
    return tf.gather(y, perm, axis=-1)

preprocess = tf.keras.applications.efficientnet.preprocess_input
AUTOTUNE = tf.data.AUTOTUNE

test_ds = (test_raw
           .map(lambda x,y: (preprocess(tf.cast(x, tf.float32)), align_to_train_order(y)),
                num_parallel_calls=AUTOTUNE)
           .cache()
           .prefetch(AUTOTUNE))

# ---- Evaluate
model.compile(
    optimizer="adam",
    loss="categorical_crossentropy",
    metrics=[tf.keras.metrics.CategoricalAccuracy(name="accuracy"),
             tf.keras.metrics.TopKCategoricalAccuracy(k=3, name="top3_acc")]
)
loss, acc, top3 = model.evaluate(test_ds, verbose=0)
print(f"\nTest metrics — acc: {acc:.4f}, top3: {top3:.4f}, loss: {loss:.4f}")

# ---- Predictions for confusion matrix
y_true = []
for _, y in test_ds.unbatch().batch(1024):
    y_true.append(y.numpy())
y_true = np.concatenate(y_true, axis=0)
true_idx = y_true.argmax(axis=1)

y_pred = model.predict(test_ds, verbose=0)
pred_idx = y_pred.argmax(axis=1)

cm = confusion_matrix(true_idx, pred_idx, labels=range(num_classes))
per_class_acc = (cm.diagonal() / cm.sum(axis=1).clip(min=1)).astype(float)

print("\nPer-class accuracy:")
for c, a in zip(train_classes, per_class_acc):
    print(f"  {c:16s} : {a:0.4f}")

print("\nClassification report:")
print(classification_report(true_idx, pred_idx, target_names=train_classes, digits=4))

# ---- Pretty confusion matrix (row-normalized)
cm_norm = cm / cm.sum(axis=1, keepdims=True).clip(min=1)
plt.figure(figsize=(8,7))
plt.imshow(cm_norm, interpolation='nearest')
plt.title("Confusion Matrix (row-normalized)")
plt.colorbar()
tick_marks = np.arange(num_classes)
plt.xticks(tick_marks, train_classes, rotation=45, ha='right')
plt.yticks(tick_marks, train_classes)
plt.xlabel("Predicted"); plt.ylabel("True")
plt.tight_layout()
plt.show()
Safe load failed for /content/efficientnetb0_aug_best_fixed.keras: The `function` of this `Lambda` layer is a Python lambda. Deserializing it is unsafe. If you trust the source of the config artifact, you can override this error by passing `safe_mode=False` to `from_config()`, or calling `keras.config.enable_unsafe_deserialization().
Unsafe load also failed for /content/efficientnetb0_aug_best_fixed.keras: Exception encountered when calling Lambda.call().

We could not automatically infer the shape of the Lambda's output. Please specify the `output_shape` argument for this Lambda layer.

Arguments received by Lambda.call():
  • args=('<KerasTensor shape=(None, 224, 224, 3), dtype=float32, sparse=False, ragged=False, name=keras_tensor_550>',)
  • kwargs={'mask': 'None'}
Loaded model (safe): /content/efficientnetb0_noaug_best.keras
Found 10235 files belonging to 10 classes.
Train classes: ['altar', 'apse', 'bell_tower', 'column', 'dome(inner)', 'dome(outer)', 'flying_buttress', 'gargoyle', 'stained_glass', 'vault']
Found 1473 files belonging to 10 classes.
Test classes:  ['altar', 'apse', 'bell_tower', 'column', 'dome(inner)', 'dome(outer)', 'flying_buttress', 'gargoyle', 'stained_glass', 'vault']

Test metrics — acc: 0.9613, top3: 0.9966, loss: 0.2625

Per-class accuracy:
  altar            : 0.9500
  apse             : 0.8421
  bell_tower       : 0.9471
  column           : 0.9762
  dome(inner)      : 0.9651
  dome(outer)      : 0.9940
  flying_buttress  : 0.9615
  gargoyle         : 0.9832
  stained_glass    : 0.9691
  vault            : 0.9329

Classification report:
                 precision    recall  f1-score   support

          altar     0.9236    0.9500    0.9366       140
           apse     0.9231    0.8421    0.8807        57
     bell_tower     0.9471    0.9471    0.9471       170
         column     0.9951    0.9762    0.9856       210
    dome(inner)     0.9326    0.9651    0.9486        86
    dome(outer)     0.9709    0.9940    0.9824       168
flying_buttress     0.9259    0.9615    0.9434        78
       gargoyle     0.9832    0.9832    0.9832       238
  stained_glass     0.9812    0.9691    0.9752       162
          vault     0.9503    0.9329    0.9415       164

       accuracy                         0.9613      1473
      macro avg     0.9533    0.9521    0.9524      1473
   weighted avg     0.9615    0.9613    0.9612      1473

No‑augmentation run: training accuracy shot up (~0.99+) while val accuracy plateaued ~0.96 and val loss crept up late → mild overfitting.

Augmented run (after fixing class‑order + preprocessing): val accuracy stayed in the ~0.96 band with steadier loss → regularization worked without hurting generalization.

Early stopping + LR scheduling did the right thing (training didn’t waste epochs once val stopped improving).